feat(billing): take Google Play purchases and notifications - #282
Merged
Conversation
The half of the Play adapter that does not need Google: the two endpoints, the notification envelope, the state mapping and the replay guard. What is missing is GooglePlayBillingService itself, which needs a Play Console that exists - until then NoopBillingService stands in and nothing is granted. POST /billing/play/purchases is authenticated because it is the only place the link between a purchase and an account is ever learned: Google's notifications name a token and a product and nothing else. It trusts the client for nothing - the token is handed to Google and what comes back is stored - so a purchase nobody can confirm links as PENDING with no badge, which is what lets the nightly reconcile finish it later. POST /billing/play/notifications takes the Pub/Sub push behind a shared secret on the URL; empty keeps it closed, which is the right default for an unauthenticated route that writes billing state. The notification is a nudge and never state - deliveries are unordered and redelivered, so what changed is read back from Google. It answers 204 for anything it understood, including a redelivery and a purchase no account claims, because Pub/Sub retries every non-2xx and retrying those achieves nothing. An unrecognised subscription state reads as not entitling. Google adds values over time and the safe reading of "I do not know this" is that the badge is off, not on.
|
🎉 This PR is included in version 1.27.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #276. This is the half of the Play adapter that does not need Google — the endpoints, the notification envelope, the state mapping and the replay guard.
GooglePlayBillingServiceitself needs a Play Console that exists, so the issue stays open.Summary
POST /billing/play/purchases— authenticated. The app hands over thepurchaseTokenthe billing library produced, right after Play reports a successful purchase.This call is authenticated for a specific reason: it is the only place the link between a purchase and an account is ever learned. Google's notifications name a token and a product and nothing else. It trusts the client for nothing else — the token goes straight to Google for verification and what comes back is what gets stored, so a fabricated token buys a row that says
PENDINGand no badge. A purchase Google cannot confirm right now is still linked, asPENDING, because without the row nothing would ever connect that token to that account again; the nightly reconcile finishes it.POST /billing/play/notifications— where Pub/Sub pushes. No session, guarded by a shared secret on the URL (PLAY_NOTIFICATIONS_TOKEN). Empty means closed, which is the right default for an unauthenticated route that writes billing state.The notification is treated as a nudge, never as state: it says a purchase changed, and what it changed to is then read from the Play Developer API. Deliveries are unordered and redelivered, so believing their contents would mean reinstating subscriptions that have ended. Every delivery is recorded in
billing_eventsfirst, which spots a redelivery and leaves the trail that is the first thing anybody wants when a subscription is in the wrong state.It answers 204 for everything it understood — including a redelivery, and a purchase no account claims yet, which happens legitimately when Google pushes before the app's own call arrives. Pub/Sub retries every non-2xx, and retrying either of those achieves nothing. A genuine failure escapes as a 5xx, which is exactly the answer that makes Google try again.
mapPlayStateis the single place Google's vocabulary becomes ours.ACTIVEandIN_GRACE_PERIODentitle;PAUSED,ON_HOLD,CANCELEDandEXPIREDdo not, since none of them are being paid for. A state we do not recognise reads as not entitling — Google adds values over time, and the safe reading of "I do not know this" is that the badge is off.What is deliberately not here
GooglePlayBillingService— theBillingPortimplementation that calls the Play Developer API to verify and to cancel. It needs the Play Console side: a subscription product, a service account with "View financial data" and "Manage orders and subscriptions", and the Pub/Sub topic. Until it existsNoopBillingServicestands in, so purchases link asPENDINGand no badge is granted anywhere.Verifying the OIDC token Google can attach to a push is the stronger alternative to the shared secret and belongs with that same work; the secret is a real guard in the meantime, not a placeholder.
The purchase endpoint should also carry
idempotency: trueonce #281 is onmain— it is left off here only because the flag's type lives on that branch. A retried hand-over is already harmless: the sync writes absolute state onto one row per account.Tests
mapPlayStateacross entitling, non-entitling and unknown states;PlayNotificationServiceasking Google rather than believing the notification, doing nothing twice on a redelivery, refusing to invent an account for an unclaimed purchase, leaving the row alone when Google cannot say, and recording the delivery before acting;RegisterPlayPurchaseUseCaseverifying before granting, storing what the provider says rather than what the client sent, and linking an unconfirmable purchase as pending.tests/e2e/billing/play.test.ts: an unconfirmable purchase links without a badge and shows up asPENDINGonGET /billing/subscription; the endpoint needs a session; an empty token is rejected; the notification endpoint refuses both a missing and a wrong secret.tsc -p tsconfig.build.json --noEmit,eslint,prettier --checkclean.One new setting,
PLAY_NOTIFICATIONS_TOKEN, empty by default — so this merges and deploys with the notification endpoint closed and nothing else changed.AI Asistan: Opus 5